Пока я только начинаю разбираться с хаскеллем, многие его фишки выглядят страшно и непонятно. Всё-таки велика разница между ним и привычными императивными языками. Но я всё равно продолжаю: очень нравится лаконичность и ёмкость кода. А ещё он здорово заставляет думать иначе, другими категориями. Приходится мыслить декларативно, не сильно отвлекаясь на последовательность действий, которые приведут к результату.
Писать на нём пока сложно. Непривычен синтаксис, незнакомы библиотеки, непросто было даже подобрать себе удобную среду разработки. Очень много времени тратится в попытках постичь документацию к модулям, которые я использую в проекте. Почти нет примеров кода, в которых можно было бы подсмотреть, как пользоваться той или иной функцией, когда стандартного описания не хватает. Да что там -- документация к библиотекам, как правило, просто отвратительная. Но постепенно код становится всё более и более прозрачным и понятным; одно это уже приносит невероятное удовольствие. И потихоньку начинаешь понимать, почему некоторые называют его Python4.
Я попробую записывать мысли, здорово упростившие мне понимание концепций языка. Надеюсь удержать уровень повествования выше "собственного блокнота, который никому не надо показывать". Хочется не просто зафиксировать прорывные этапы понимания, но объяснить их своим языком так, чтобы, возможно, помочь кому-то ещё "схватить" язык.
Самое важное: символ =
-- это именно математический знак равенства, а не присваивания, как во многих других языках. Мы как бы говорим, что левая сторона выражения эквивалентна правой. Не правда ли, сразу понятнее выглядит синтаксис pattern matching?
func [] = "nil"
func (x:xs) = x ++ "," ++ func xs
Мы даём определение двум выражениям:
func
от пустого списка равна строке "nil"
;func
от непустого списка равна сумме головы списка, запятой и функции func
от хвоста списка.В эту же парадигму отлично ложится определение констант. Константа -- как бы функция "от ничего", которая при любом x
будет равна какому-то одному значению y
. Приведу пример из отличной книжки Learn you a Haskell for great good:
largestDivisible :: Integer
largestDivisible = head (filter p [100000,99999..])
where p x = x `mod` 3829 == 0
Что здесь происходит? Функция largestDivisible
не принимает никаких значений. Определён только её выходной тип -- Integer
. Если вычислить выражение, которому равна функция, получится 99554
: самое большое число из диапазона от нуля до 100000, нацело делящееся на 3829. Вот этому значению и будет равно выражение largestDivisible
в течение всего времени выполнения программы. Константа!
В целом синтаксис языка больше похож на запись формул, уравнений, а не на язык программирования. let ... in
и where
чего только стоят. Это же классическое
$E=mc^2$, где m -- масса тела, а c -- скорость света в вакууме.
Учитывая сказанное, гарды (guards) можно воспринимать как систему уравнений с ограничениями. Сравните:
isNegative x
| x < 0 = "Negative"
| x == 0 = "Zero"
| otherwise = "Positive"
$isNegative = \begin{cases} Negative, & \text{если x < 0} \\ Zero, & \text{если x = 0} \\ Positive, & \text{если x > 0} \end{cases}$
Очень похоже!
Такой декларативный подход позволяет хаскеллю быть языком с ленивой моделью вычислений. Программист описывает не последовательность действий, а связи между сущностями. Детализируя их и их взаимодействия до нужной степени. А компилятор уже сам разбирается, когда что выполнять (и выполнять ли вообще). Собственно, работа компилятора заключается в том, чтобы максимально упростить вычисляемое выражение (программу). Если дальше упрощать некуда -- всё, программа завершена, результатом её работы является это самое максимально упрощённое выражение. Очень красиво с математической точки зрения.
Поначалу код на хаскелле выглядит странно. Из кода функций совершенно непонятно, что они делают, даже если в коде нет "страшных значков" типа <$>
или <*>
. Вот вроде бы всё просто здесь, а всё равно неясно, куда девается String, объявленный в декларации функции:
getBetweenColons :: String -> String
getBetweenColons =
let beforeColon = takeWhile (/= ':')
afterColon = dropWhile (/= ':')
in init . beforeColon . tail . afterColon
Оказывается, всё просто. Считайте, что это приведение подобных членов, школьная алгебра. Если в левой части выражения стоит одинокий x
, и в правой части тоже стоит одинокий x
, их обоих можно выкинуть. Следующие две функции равноценны:
getLinesWith :: String -> String -> [String]
getLinesWith t c = filter (isSpecialLine t) . lines $ c
getLinesWith :: String -> String -> [String]
getLinesWith t = filter (isSpecialLine t) . lines
Значение c
мы можем выкинуть, т.к. внутри getLinesWith
оно "съедается" в первую очередь -- функцией lines
. А вот t
выкинуть нельзя, потому что тогда компилятору будет непонятно, какое из двух значений куда скармливать.
Теперь понятно, что в первом примере аргумент функции приходит сначала в afterColon
, потом к получившемуся применяют tail
, ну и так далее по цепочке, пока результат функции init
не будет отдан наружу. А писать аргумент (ещё и именовать его! пфф) до знака равенства и после afterColon
просто необязательно: компилятор сам догадается, что тут к чему. Удобно.
За этой механикой стоит идея каррирования (частичного применения) функций. Ну и композиция оных, конечно: то самое
(f . g) x = f (g x)
. На самом деле, убирая аргумент из тела функции, мы создаём частично применённую функцию, которая ждёт свой единственный аргумент. Записьz x = (f . g) x
равноценна записиz = f . g
. Потому что в коде мы как раз применимz
к недостающему аргументу, вызвав её какz x
.
Кстати, если пользоваться линтером (например, HLint), он будет сразу советовать такое упрощение (Eta-reduce).
Вот написал уже немного своих аналогий и пониманий, но прочитал последний пункт этого списка и осёкся. Правильно: единственный способ врубиться в эти штуки --